多语言切换

多语言切换 - 重启 App

目标:和微信类似,在设置界面打开切换语言的界面,选择语言后重启 HomeActivity,语言切换完成,下次重新打开 App ,也是用户设置的语言。

多语言切换注意

ApplicationContext/Activity/Resources.getSystem() 区别

  1. Application/Activity 的 Locale 是分开的要分别设置 Locale;Resources.getSystem() 是跟随系统语言的
  2. 某些手机中,弹出 Toast 时如果使用的是 getApplicationContext(),弹出的语言是系统默认的语言,所以最好都传 Activity 的 Context

全局 Context/全局 Resource 的引用(单例/枚举)

public class ResUtils {
    private static Resources res = GlobalContext.getAppContext().getResources();
    public static String getStr(@StringRes int resID) {
        return res.getString(resID);
    }
}

工具类中全局缓存了 Resource 了,那么在不杀进程的重启切换这部分语言资源是切换不过来的。

  1. 单例
// 单例
object ObjTest {
    val name: String = ResUtils.getStr(R.string.title_tab_recommend)
    val name1: String =
        GlobalContext.getAppContext().resources.getString(R.string.title_tab_recommend)
}
  1. 枚举
// 枚举
enum class EnumTest(val s: String) {
    ONE(ResUtils.getStr(R.string.title_tab_recommend));
}
  1. 全局引用 Resource
public class ResUtils {
    private static Resources res = GlobalContext.getApplication().getResources();
}

部分手机需要给 Local 设置语言还有国家才成效,所以最好都设置国家或地区

失效 -WebView 加载会重置语言设置 (Android7.0 及 +)

你的 app 加载了 WebView 你会发现语言又变回了系统默认的默认语言

在 Android 7 之前 WebView 的渲染是通过 Android System webView 来实现的。但是在 Android7 之后 WebView 会被作为一个应用程序的方式服务于各个三方 APP。由于 WebView 这里是作为一个单独的应用程序,所以他不会被绑定到你自己 APP 设置的 Local 上。不仅如此,WebView 还会把语言变成设备的 Local 设置。然后相应的资源文件也会被变成设备语言下的资源文件这样就导致了只要打开了含有 WebView 的页面,应用内语言设置就失效的问题。

//处理Android7(N)WebView 导致应用内语言失效的问题
public static void destoryWebView(Context context) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        new WebView(context).destroy();
    }
}
// 切换语言
Resources resources = context.getResources();
DisplayMetrics dm = resources.getDisplayMetrics();
Configuration config = resources.getConfiguration();
config.locale = getLocaleByType(type);
LogUtils.logd("setLocale: " + config.locale.toString());
resources.updateConfiguration(config, dm);

在 app 启动时就加载一次 WebView ,然后在设置语言,只要 WebView 第一次加载后修改了语言,后面再加载便不会重置为系统语言。

public class AppApplication extends CommonApplication {
    @Override
    public void onCreate() {
        super.onCreate();
        Looper.myQueue().addIdleHandler(() -> {
            LogUtils.i("webView preload.");
            WebViewPreLoader.getInstance().preLoad(getApplicationContext());
            MultiLangUtils.applyLanguage(getApplicationContext(), MultiLangUtils.getUserSettingLocale(getApplicationContext()), null);
            return false;
        });
    }
}
Looper.myQueue().addIdleHandler(() -> {
    WebViewPreLoader.getInstance().preLoad(getApplicationContext());
    return false;
});

失效 - 切换系统语言/横竖屏切换(屏幕旋转)

  1. 系统切换语言,会把 Activity/ApplicationContext 的 Locale 更改
  2. 系统切换语言会走 Application 的 onConfigurationChanged,所以需要在这个方法中再设置一遍语言
class BaseApplication : Application() {
    @Override
    public void onConfigurationChanged(@NotNull Configuration newConfig) {
        MultiLangUtils.applyLanguage(this, MultiLangUtils.getUserSettingLocale(this), null);
        super.onConfigurationChanged(newConfig);
    }
}

失效 - 使用微信开源的热修复框架 Tinker,打了包含资源的补丁之后会导致多语言失效

如果打了包含资源 string 文件的补丁之后,会导致多语言失效,本来选的繁体变成了简体语言,同时无论你怎么切换语言,都没有生效。这属于 Tinker 的 bug,已经有人在 Tinker 的 github 主页上反馈了,但是这个 issue 任然没有关闭:https://github.com/Tencent/tinker/issues/302

Toolbar 或者 ActionBar 的 title 切换语言不起作用

默认 title 是从 AndroidManifest.xml 中 Activity 的 label 标签里读取的,我们在代码里手动设置一下 title 即可

主题用的是:

Theme.AppCompat.Light.DarkActionBar

手动设置

//Toolbar
toolbar.setTitle(R.string.app_name);
//ActionBar
actionBar.setTitle(R.string.title_activity_settings);

Activity 和 V7#AppcompatActivity 的返回 Locale 区别

只有 values-zhvalues-en 资源,语言设置:“中文简体 → 日语 → 英语”

  1. 用 Activity
LocaleList.getDefault()        : zh_CN_#Hans,ja_JP,en_US,
Configuration.getLocales()     : en_US,zh_CN_#Hans,ja_JP,
LocaleList.getAdjustedDefault(): en_US,zh_CN_#Hans,ja_JP,
  1. 当项目引用了 AppCompat-v7 包后(即便你的所有 Activity 继承的仍然只是原生的 Activity,而非 AppCompatActivity)
LocaleList.getDefault()        : zh_CN_#Hans,ja_JP,en_US,
Configuration.getLocales()     : zh_CN_#Hans,ja_JP,en_US,
LocaleList.getAdjustedDefault(): zh_CN_#Hans,ja_JP,en_US,

返回的都是系统实际的语言列表

大概 google 在这个包里做了处理,屏蔽了系统根据应用提供的资源调整语言列表的功能,相当于让一切回到 7.0 以前的版本。


多语言切换

什么是 Locale

Locale 是 JavaSE 中一个类,用以表示本地语言的类型,可用于日历、数字和字符串的本地化。

字段 含义 格式 示例
language 国际现有的语言表示 2 或 3 个字母,皆小写 zh- 中文 (拼音缩写),en-english
country(region) 国家或地区 国家 2 个字母 (大写),区域 3 数字 CN- 中国,US- 美国,030-Eastern Asia(东亚)
script 区分语言或其方言书写形式的脚本 4 个字母,首字母大写其余小写 Hans- 简体中文,Hant- 繁体中文,Latn- 拉丁文
variant 其他可用子标签未涵盖的语言或其方言的语言变体 字母开头至少 5 位,数字开头至少 4 位 pinyin- 须有前缀 zh-Latn
extensions 从单个字符键到字符串值的映射扩展 2-8 字母或数字 ca-japanese(Japanese Calendar)
// 传入语言生成Locale,country与variant为空
Locale(String language)
// 语言+国家,variant为空
Locale(String language, String country)
// 语言+国家+variant
Locale(String language, String country, String variant)
// 通过设置各个字段来构建Locale,这种方式比构造函数要精确,并且会判断传入的值是否符合Locale类定义的语法要求
Locale aLocale = new Builder().setLanguage("zh").setScript("Hans").setRegion("CN").build();
Locale[] locales = Locale.getAvailableLocales();
for (Locale locale : locales) {
    System.out.println("语言:"+locale.getDisplayLanguage()+",国家:"+locale.getDisplayCountry()+","+locale);
}

注:Locale.getScript() 方法是在 Android API21 中才新增的。

添加多语言文件(在 res 资源文件目录下添加不同语言的 values)

values 文件夹的命名规则如下

  1. 语言通过由两个字母组成的 ISO 639-1 语言代码定义,可以选择后跟两个字母组成的 ISO 3166-1-alpha-2 区域码(前带小写字母 "r")。
  2. 这些代码不区分大小写;r 前缀用于区分区域码。 不能单独指定区域。

示例:values-zh,values-zh-rCN,values-zh-rTW,values-en-rUS

values 匹配规则

当应用启动的时候,系统会根据当前的语言环境自动去匹配对应的 values 文件夹,匹配规则如下:

  1. 7.0 之前,先匹配与当前应用 Configuration 语言一致的资源 (language,country 相同),如没有再匹配 language 一致的资源 (命名中只有 language,如 values-en),如无则使用默认资源。
  2. 7.0 之后,系统语言设置中可添加多个语言,优先匹配规则与上述一样,不过添加了可匹配同一语言不同国家的资源,即 language 与 country 都没匹配上,也可匹配同一个 language 但不同 country 的资源,即是同一父项下的不同子项。
    如果第一语言没有对应资源匹配,可继续查找匹配第二位的语言,这就是语言列表的作用。如果列表中的语言都没匹配上,则使用默认资源。
  3. 特别注意点: 简体中文与繁体中文不是同一体系的
    • 示例 1:语言设置为简体中文,没有 values-zh-rCN 的资源,即使有 values-zh-rTW 或者 values-zh-rHK 的资源也不会使用,而会使用默认的资源。
    • 示例 2: 语言设置为繁体中文,没有 values-zh-rTW 的资源,即使有 values-zh 或 values-zh-rCN 的资源也不会使用,而会使用默认资源。

在不同的 value 文件夹下(例如 valuevalue-envalues-zh-rTW 文件夹)添加不同语言的 string.xml 文件,如图:
a8m0m

Android7.0 之后资源如何匹配

通过修改 Configuration 中的 locale 来实现 app 语言的切换(区分 Android7.0+/Android7.0 以下)

Configuration 包含了设备的所有的配置信息,这些配置信息会影响应用获取的资源。例如 string 资源,就是根据 Configuration 的 locale 属性来判断该取哪种语言的 string 资源,默认是 value 文件夹下的。

  1. Android7.0 及以前版本,Configuration 中的语言相当于是 App 的全局设置
public static void changeAppLanguage(Context context, Locale newLocale){
    Resources resources = context.getResources();
    Configuration configuration = resources.getConfiguration();
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
     configuration.setLocale(newLocale);
    } else {
     configuration.locale = newLocale;
    }
    // updateConfiguration
    DisplayMetrics dm = resources.getDisplayMetrics();
    resources.updateConfiguration(configuration, dm);
}

如果你需要设置的语言没有预设值,你可以自己新建一个 Locale 对象(如土耳其 Locale("tr", "TR"));跟随系统设置是 Locale.getDefault()

  1. Android7.0 及之后版本,使用了 LocaleList,Configuration 中的语言设置可能获取的不同,而是生效于各自的 Context。这会导致:Android7.0 使用就的方式,有些 Activity 可能会显示为手机的系统语言。
  2. Android7.0 优化了对多语言的支持,废弃了 updateConfiguration() 方法,替代方法:createConfigurationContext(), 而返回的是 Context。
fun changeLanguage(context: Context, newUserLocale: Locale) {
    val resources: Resources = context.resources
    val dm: DisplayMetrics = resources.displayMetrics
    val config: Configuration = resources.configuration
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        val localeList = LocaleList(newUserLocale)
        LocaleList.setDefault(localeList)
        config.setLocales(localeList)
        Locale.setDefault(newUserLocale)
        resources.updateConfiguration(config, dm) // 不加这句切换不成功
        context.createConfigurationContext(config)
    } else {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
            config.setLocale(newUserLocale)
        } else {
            config.locale = newUserLocale
        }
        resources.updateConfiguration(config, dm)
    }
}

重启到 HomeActivity(是否杀进程重启?)

Intent intent = new Intent(this, HomeActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
getActivity().startActivity(intent);

正常来说这段代码应该是没问题的,但是假如你的 App 存在某个 activity 和当前设置页 activity 不在一个 task 栈内的话(比如你从某个通知页用 FLAG_ACTIVITY_NEW_TASK 启动的一个 activity),就不会应用语言设置。因此可以直接杀掉当前 App 的进程,保证是 " 整个 " 重启了:

Intent intent = new Intent(this, HomeActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(intent);
// 杀掉进程
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(0);

持久化存储语言设置 (进程重启、屏幕旋转等配置更新)

当你杀掉应用,重新打开,发现设置又失效了。这是因为应用重启后会读取设备默认的 Configuration 信息,其中和语言相关的 locale 属性也会变成默认值,也就是你在系统设置中选择的语言。

  1. App 进程重启失效,需要重新设置用户选择的语言
  2. 屏幕旋转等导致的 onConfigurationChanged 调用,需要重新设置用户选择的语言
class BaseApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        MultiLangUtils.init(this)
    }
    
    // onConfigurationChanged 用于适配横竖屏切换。因为横竖屏切换属于系统配置信息的更新,此时Android会更新ApplicationContext中的Resource对象(ApplicationContext对象并未新建,只是更新了其中的Resource对象)
    override fun onConfigurationChanged(newConfig: Configuration?) {
        MultiLangUtils.applyLanguage(this, MultiLangUtils.getUserSettingLocale(this))
        super.onConfigurationChanged(newConfig)
    }
}

核心工具类代码

object MultiLangUtils {

    const val TAG = "locale"

    /**
     * 保存SharedPreferences的文件名
     */
    private const val LOCALE_FILE = "LOCALE_FILE"

    /**
     * 保存Locale的key
     */
    private const val LOCALE_KEY = "LOCALE_KEY"

    private val gson = Gson()

    @JvmStatic
    fun init(app: Application) {
        val userSettingLocale = getUserSettingLocale(app)
        if (needUpdateLocale(app, userSettingLocale)) {
            LogUtils.d(TAG, "init 需要更新语言为($userSettingLocale), app=$app")
            changeLanguage(app, userSettingLocale)
        } else {
            LogUtils.w(TAG, "init 不需要更新语言当前语言($userSettingLocale), app=$app")
        }
        app.registerActivityLifecycleCallbacks(object :
                EmptyActivityLifecycleCallbacks {
                override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
                    super.onActivityCreated(activity, savedInstanceState)
                    LogUtils.d(TAG, "------>>onActivityCreated context=$activity, locale=${activity.getLocale()}")
                    val settingLocale = getUserSettingLocale(activity)
                    if (!needUpdateLocale(activity, settingLocale)) {
                        LogUtils.w(TAG, "------>>onActivityCreated applyLanguage 不需要更新语言当前语言为($settingLocale), context=$activity")
                    } else {
                        LogUtils.d(TAG, "------>>onActivityCreated applyLanguage 需要更新语言为($settingLocale), context=$activity")
                        changeLanguage(activity, settingLocale)
                        LogUtils.d(TAG, "------>>onActivityCreated 需要更新语言后,locale=${activity.getLocale()}, userSettingLocale=${getUserSettingLocale(activity)}")
                    }
                }

                override fun onActivityResumed(activity: Activity) {
                    super.onActivityResumed(activity)
                    LogUtils.d(TAG, "------->>onActivityResumed context=$activity, locale=${activity.getLocale()}")
                    val settingLocale = getUserSettingLocale(activity)
                    if (!needUpdateLocale(activity, settingLocale)) {
                        LogUtils.w(TAG, "------->>onActivityResumed applyLanguage 不需要更新语言当前语言为($settingLocale), context=$activity")
                    } else {
                        LogUtils.i(TAG, "------->>onActivityResumed applyLanguage 需要更新语言为($settingLocale), context=$activity")
                        changeLanguage(activity, settingLocale)
                        LogUtils.i(TAG, "------->>onActivityResumed 需要更新语言后,locale=${activity.getLocale()}, userSettingLocale=${getUserSettingLocale(activity)}")
                    }
                }
            })
    }

    /**
     * 获取系统的Locale
     */
    private fun getSystemLocale(): Locale {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // 7.0有多语言设置获取顶部的语言
            Resources.getSystem().configuration.locales[0]
        } else {
            Resources.getSystem().configuration.locale
        }
    }

    /**
     * 获取用户设置的Locale
     *
     * @param context Context
     * @return Locale
     */
    @JvmStatic
    fun getUserSettingLocale(context: Context): Locale {
        val sp = context.getSharedPreferences(LOCALE_FILE, Context.MODE_PRIVATE)
        val savedLocaleJson = sp.getString(LOCALE_KEY, "")
        if (savedLocaleJson.isNullOrBlank()) {
            LogUtils.w(TAG, "LocaleSwitchUtils#getUserSettingLocale(savedLocaleJson=null),获取getCurrentLocale(${getCurrentLocale(context)}), context=$context")
            return getCurrentLocale(context)
        }
        LogUtils.d(TAG, "LocaleSwitchUtils#getUserSettingLocale(获取用户设置的Locale)=$savedLocaleJson, context=$context")
        return jsonToLocale(savedLocaleJson)
    }

    /**
     * 获取当前的Locale
     *
     * @param context Context
     * @return Locale
     */
    fun getCurrentLocale(context: Context): Locale {
        val locale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // 7.0有多语言设置获取顶部的语言
            context.resources.configuration.locales[0]
        } else {
            context.resources.configuration.locale
        }
        LogUtils.d(TAG, "LocaleSwitchUtils#getCurrentLocale(获取当前的Locale)=$locale, context=$context")
        return locale
    }

    /**
     * 保存用户设置的Locale
     *
     * @param context Context
     * @param locale  Locale
     */
    private fun saveUserSettingLocale(context: Context, locale: Locale) {
        val sp =
            context.getSharedPreferences(LOCALE_FILE, Context.MODE_PRIVATE)
        val edit = sp.edit()
        val localeToJson =
            localeToJson(locale)
        val isSuccess = edit.putString(LOCALE_KEY, localeToJson).commit()
        LogUtils.e(TAG, "LocaleSwitchUtils#saveUserSettingLocale(保存用户设置的Locale)=$locale, context=$context, isSuccess=$isSuccess")
    }

    /**
     * Locale转成json
     *
     * @param locale UserLocale
     * @return json String
     */
    private fun localeToJson(locale: Locale): String {
        return gson.toJson(locale)
    }

    /**
     * json转成Locale
     *
     * @param pLocaleJson LocaleJson
     * @return Locale
     */
    private fun jsonToLocale(pLocaleJson: String?): Locale {
        return gson.fromJson(pLocaleJson, Locale::class.java)
    }

    fun applySystemLanguage(context: Context, activityClassName: String? = null) {
        val systemLocale = getSystemLocale()
        if (!needUpdateLocale(context, systemLocale) &&
            !needUpdateLocale(context.applicationContext, systemLocale)
        ) {
            return
        }
        changeLanguage(
            context.applicationContext,
            systemLocale
        )
        changeLanguage(context, systemLocale)
        saveUserSettingLocale(
            context,
            systemLocale
        )
        if (!activityClassName.isNullOrBlank()) {
            restartActivity(
                context,
                activityClassName
            )
        }
    }

    @JvmStatic
    fun applyLanguage(
        context: Context,
        newUserLocale: Locale,
        activityClassName: String? = null,
        saveConfig: Boolean = false
    ) {
        if (!needUpdateLocale(context, newUserLocale) &&
            !needUpdateLocale(context.applicationContext, newUserLocale)
        ) {
            LogUtils.w(TAG, "applyLanguage 不需要更新语言 ${getUserSettingLocale(context)}, context=$context")
            return
        }

        changeLanguage(
            context.applicationContext,
            newUserLocale
        )

        changeLanguage(
            context,
            newUserLocale
        )

        if (saveConfig) {
            saveUserSettingLocale(
                context,
                newUserLocale
            )
        }

        if (!activityClassName.isNullOrBlank()) {
            restartActivity(
                context,
                activityClassName
            )
        }
    }

    /**
     * 设置语言类型
     */
    fun changeLanguage(context: Context, newUserLocale: Locale) {
        val resources: Resources = context.resources
        val dm: DisplayMetrics = resources.displayMetrics
        val config: Configuration = resources.configuration
        LogUtils.e(TAG, "changeLanguage修改语言为:$newUserLocale,context=$context")
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            val localeList = LocaleList(newUserLocale)
            LocaleList.setDefault(localeList)
            config.setLocales(localeList)
            Locale.setDefault(newUserLocale)
            resources.updateConfiguration(config, dm) // 不加这句切换不成功
            context.createConfigurationContext(config)
        } else {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
                config.setLocale(newUserLocale)
            } else {
                config.locale = newUserLocale
            }
            resources.updateConfiguration(config, dm)
        }
        LogUtils.e(TAG, "changeLanguage语言后当前语言为:${getCurrentLocale(context)},期望语言为:$newUserLocale,context=$context")
    }

    /**
     * 判断需不需要更新
     *
     * @param context       Context
     * @param newUserLocale New User Locale
     * @return true / false
     */
    private fun needUpdateLocale(context: Context, newUserLocale: Locale?): Boolean {
        return newUserLocale != null && !getCurrentLocale(context).isSameLanguage(newUserLocale)
    }

    /**
     * 是否是设置值
     *
     * @return 是否是设置值
     */
    fun isSetValue(context: Context): Boolean {
        return needUpdateLocale(
            context,
            getUserSettingLocale(context)
        )
    }

    /**
     * 重启当前Activity
     */
    private fun restartActivity(context: Context, clazz: Class<out Activity?>?) {
        val intent = Intent(context, clazz)
        if (context !is Activity) {
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        }
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK)
        context.startActivity(intent)
    }

    /**
     * 重启当前Activity
     */
    private fun restartActivity(context: Context, activityClassName: String) {
        val intent = Intent()
        intent.component = ComponentName(context, activityClassName)
        if (context !is Activity) {
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        }
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK)
        context.startActivity(intent)
    }
}

使用:

class BaseApplication : Application() {
    companion object {
        const val TAG = "locale"
    }

    override fun onCreate() {
        super.onCreate()
        MultiLangUtils.init(this)
    }

    override fun onConfigurationChanged(newConfig: Configuration?) {
        Log.i(TAG, "BaseApplication onConfigurationChanged newConfig=${newConfig}")
        MultiLangUtils.applyLanguage(
            this,
            MultiLangUtils.getUserSettingLocale(this),
            saveConfig = true
        )
        super.onConfigurationChanged(newConfig)
    }
}

如果有 webview 预加载的功能,需要在加载 webview 后再次设置

public class AppApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        Looper.myQueue().addIdleHandler(() -> {
            WebViewPreLoader.getInstance().preLoad(getApplicationContext());
            Context context = ForegroundCallbacks.get().currentActivity();
            if (context == null) {
                context = getApplicationContext();
            }
            MultiLangUtils.applyLanguage(context, MultiLangUtils.getUserSettingLocale(getApplicationContext()), null, false);
            return false;
        });
    }
}

Ref

android7.0 切换注意

详解解释了 Android6.0 和 7.0 系统加载资源的流程差异

替换 updateConfiguration https://github.com/captain-miao/MultiLanguagesSwitch https://www.cnblogs.com/Sharley/p/9155824.html

无需重启 App 语言切换

https://github.com/MichaelJokAr/MultiLanguages

其他